Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins - #5956
Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins#5956glageju wants to merge 25 commits into
Conversation
The D8 ownership labels stacklok.component and stacklok.product are meant to be frozen per-series identity, but they were applied to the OTEL resource before CustomAttributes and WithFromEnv. Because resource detectors merge last-wins, a CLI custom attribute or OTEL_RESOURCE_ATTRIBUTES entry for either key silently overrode the frozen values, letting stacklok_component and stacklok_product drift in the Prometheus output. Apply the two reserved attributes as the final resource detector so they always win over user- and env-supplied attributes.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5956 +/- ##
==========================================
- Coverage 72.61% 72.60% -0.01%
==========================================
Files 736 736
Lines 76340 76372 +32
==========================================
+ Hits 55431 55452 +21
- Misses 16973 17008 +35
+ Partials 3936 3912 -24 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Multiple review passes on the metrics-standardization migration (proxy + vMCP -> stacklok.* semconv vocabulary) surfaced gaps left by the cutover: - Backend health gauge leaked series for backends removed from the registry (e.g. via list_changed) since it tracked membership in an internal map that only grew. MonitorBackends now takes the BackendRegistry directly and re-derives live backends from registry.List on every collection. - The health gauge collapsed BackendHealthStatus's five states into a boolean, losing degraded/unknown/unauthenticated information the registry already carries before any request completes. The gauge now emits one point per real status value. - A construction failure in core.New occurring after MonitorBackends registered its gauge callback (audit config, workflow validation, health monitor setup) never unregistered it, leaking the callback against the shared meter provider for every failed New attempt. - Docs (observability.md, virtualmcpserver-observability.md, telemetry-migration-guide.md) and four Grafana example dashboards still referenced deleted/renamed legacy metric and label names. - Minor cleanup: an unnamed "not_found" outcome string, inconsistent receiver types on telemetryBackendClient, and a discarded metric registration handle. Adds unit test coverage for the health gauge's registry-driven membership, its multi-state reporting, and a regression test proving core.New unregisters the callback on every post-registration failure path (verified by temporarily reverting the fix and confirming the new test fails). Generated with [Claude Code](https://claude.com/claude-code)
The metrics-standardization migration lost information the deleted legacy metrics carried and left downstream artifacts partially migrated: mcp.client.operation.duration had no backend identity label, three Grafana dashboards still queried deleted metric names, and the new stacklok.vmcp.mcp_server.health gauge never consulted the live health monitor, so it could disagree with capability filtering. - Add mcp_server to mcp.client.operation.duration's attributes - Migrate remaining stale panels in 3 Grafana dashboards to the semconv metric/label vocabulary - Thread a live health.StatusProvider into the backend-health gauge via a new HealthProviderSetter, matching filterHealthyBackends' precedence (live provider > recorded outcome > registry snapshot), and normalize an empty HealthStatus to healthy instead of reporting every state as 0 - Cover all 5 of New()'s post-registration error paths for backend health callback unregistration, not just one - Hoist the duplicated componentName constant into providers.ComponentName - Fix Close()'s stale doc comment and rename unregisterHealthOnError to unregisterHealthLogged (it also runs on the normal shutdown path) - Document that NewMeteredTokenCache has no production caller yet - Add the vMCP optimizer and mcp_server.health rows to the migration guide's metric mapping table Generated with Claude Code Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
error_type on the semconv metrics is only set for HTTP >=500, so the dashboard error-rate panels' error_type!="" filter silently excluded 4xx failures (authz denials, classification rejects) the old status!="success" caught. Switch those panels to the HTTP duration metric's status-code label, which sees every request. The migration guide's request-volume guidance summed mcp_server_operation_duration_seconds_count and http_server_request_duration_seconds_count, but every MCP-method-bearing request increments both, double-counting them. Use the HTTP duration metric alone for total volume. Generated with Claude Code Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Grafana total-RPS panels queried the MCP-only histogram, undercounting transport-level traffic; the migration guide pointed at a label that doesn't exist on its replacement metric; several D8/build_info/health-gauge doc sections contradicted the actual implementation; and the proxy middleware could panic on a rare instrument-registration failure. Also drops the token-cache metrics decorator shipped with no production caller.
Closes documentation and test-coverage gaps surfaced during review of the stacklok.* metrics migration: the new mcp_server label on mcp.client.operation.duration was undocumented in two places, the revision-reclassification rename was missing from the migration guide's mapping table, the health-gauge's doc comment described the wrong divergence condition from filterHealthyBackends, the Grafana sidecar's cluster-wide searchNamespace had no RBAC note or setup steps, and the D8 ownership-label wiring in providers_strategy.go had no test through its actual construction path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
glageju
left a comment
There was a problem hiding this comment.
Review Summary
Reviewed the metrics-vocabulary migration end to end: the proxy/vMCP rename to stacklok.*, the six-metric deletion, D8 ownership-label hardening, and the vMCP backend health-gauge rework.
Findings addressed in 045ccad
mcp_serverlabel missing frommcp.client.operation.duration's documentation in two places (docs/operator/virtualmcpserver-observability.md,docs/telemetry-migration-guide.md)currentHealthStatus's doc comment (pkg/vmcp/internal/backendtelemetry/backendtelemetry.go) described the wrong divergence condition fromfilterHealthyBackends— corrected to state it occurs whenprovider == nilandrecord()has an entry, not the "provider set but not yet tracking" case it originally claimed- Grafana sidecar
searchNamespace: ALL(examples/otel/prometheus-stack-values.yaml) had no RBAC note or ConfigMap setup steps — scoped to themonitoringnamespace with a comment on the tradeoff, and added thekubectl create configmap/labelsteps toexamples/otel/README.md - D8 ownership-label wiring in
providers_strategy.gohad no test through its actualUnifiedMeterStrategy.CreateMeterProviderconstruction path — addedTestUnifiedMeterStrategy_PrometheusOwnershipLabels toolhive_vmcp_backend_revision_reclassifications's rename was missing from the migration guide's mapping table
Verified, not bugs
- Using OTel dotted attribute-key constants (
stacklok.component) as Prometheus label-map keys inproviders_strategy.godoes not panicMustRegisteras initially suspected —prometheus/common'sUTF8Validationdefault (active in this repo's pinnedv0.67.5) accepts dots and escapes them to underscore form at scrape time. Confirmed by reproducing the exact registration + scrape against this repo's pinned dependencies. - The
toolhive-core v0.0.34pin ingo.modis not visible as a diff hunk because it already landed via an earlier merge frommain— the branch builds and compiles cleanly.
Overall
Callback lifecycle (register → unregister-on-every-construction-failure-path → idempotent Close()), the D8 label-override hardening, and the six metric deletions/renames are all correctly implemented and backed by tests that exercise the actual failure paths, not just the happy path — including a regression test that scrapes a live Prometheus handler to prove no gauge series leak after a construction failure.
Generated with Claude Code
aponcedeleonch
left a comment
There was a problem hiding this comment.
Request changes.
The RFC alignment here is solid, and the migration guide is more thorough than most renames get. The health-gauge test coverage stands out: the precedence tests and the registry-removal case cover things that would normally slip through.
My concern is the cutover strategy rather than the vocabulary itself.
As it stands, no ToolHive metric keeps its name. After this PR the string toolhive_ appears in zero metric names across the non-test Go source. That's about 24 metrics: 6 deleted outright and 18 renamed. A rename breaks a dashboard the same way a deletion does, so from a user's point of view every panel and alert they have goes empty on upgrade, all at once.
The PR cites RFC D13 to accept that cost, but D13's argument is that the deleted twins aren't scraped in a default shipped deployment. That covers the 6 deletions. It doesn't cover the 18 renames, which are the larger share and have no stated justification for breaking.
We already have the machinery for this. Span attributes get dual emission behind --otel-use-legacy-attributes, with #6072 tracking its retirement. Metrics went with a hard cutover instead, and I don't think the PR explains why they should be treated differently.
What I'd like instead:
- Dual-emit the 18 renamed metrics under both names, behind a flag defaulting to on.
- Keep the 6 deleted twins one more release. They already coexist with their semconv replacements today, so keeping them costs nothing new. Removing them in the same release as the mass rename is what leaves users with no working query for anything.
- Emit a startup WARN naming the deprecated metrics and the release they'll be removed in.
- Call this out prominently in the release notes as a breaking change.
- Remove both in a later minor.
Dual emission roughly doubles series for the overlap window, and histograms are the expensive case. If that's the blocker, I'd rather talk about a shorter window than skip it, because right now the only thing absorbing the breakage is our users' dashboards. Also worth flagging that green CI isn't much signal here: we ship no PrometheusRule or alert artifacts, so there's nothing in-repo for CI to catch.
One thing to watch on the rebase. The branch is 44 commits behind main and conflicts on pkg/telemetry/middleware.go. The version bump sorts itself out, but the conflict still needs a call on which histogram preset mcp.server.operation.duration ends up on: #6144 on main put it on BucketsMCPSemconv(), this branch uses BucketsMCPProxy(). Since the metric carries a semconv name I'd keep the semconv buckets. pkg/ratelimit/observability.go:80 and pkg/vmcp/core/core_telemetry.go:57 make the same substitution without conflicting textually, so they want a look too.
| func (m *HTTPMiddleware) recordHTTPServerDuration( | ||
| ctx context.Context, method string, statusCode int, duration time.Duration, | ||
| ) { | ||
| specAttrs := []attribute.KeyValue{ |
There was a problem hiding this comment.
This attribute set has no mcp_server, and neither does the one in recordOperationDuration just below. The three deleted proxy metrics (toolhive_mcp_requests, _request_duration, _tool_calls) all carried server, so per-server breakdown on request metrics goes away entirely. Only stacklok.toolhive.proxy.active_connections still has it. This needs sorting before merge.
The migration guide says otherwise at docs/telemetry-migration-guide.md:281, which maps server (proxy + rate limit metrics) to mcp_server with no caveat. That's accurate for the rate-limit metrics and wrong for these two.
Two ways out: add coremetrics.LabelMCPServer here and in recordOperationDuration, or fix that guide row and say per-server breakdown now relies on scrape-target labels or a target_info join. I'd lean toward the label, since one Prometheus scraping several proxies otherwise can't split them without a join.
Worth noting the e2e assertion for mcp_server="<name>" in test/e2e/osv_authz_test.go still passes, because active_connections supplies it. So the tests don't catch this.
There was a problem hiding this comment.
Fixed in 46864d8 — added coremetrics.LabelMCPServer to both recordHTTPServerDuration and recordOperationDuration, and fixed the migration guide row that claimed server→mcp_server applied unconditionally. Also strengthened the assertions in TestHTTPMiddleware_HTTPServerRequestDuration to check for the label going forward.
| // connection. Record the HTTP semconv duration on close so the | ||
| // long-lived SSE-open GET (which never reaches recordMetrics) still | ||
| // contributes a transport-level observation. | ||
| sseStart := time.Now() |
There was a problem hiding this comment.
This records the whole SSE connection lifetime into http.server.request.duration, which is on BucketsFastHTTP() and tops out at 10s. SSE connections live for minutes or hours, so every one lands in +Inf and any quantile over the unfiltered metric stops meaning much.
The example dashboards only use _count, so they're fine today. But docs/observability.md:234 documents the SSE behavior without mentioning the effect, so someone will build a p95 panel on this and get a flat line pinned to the top bucket.
Either record the SSE observation into a long-running bucket set, or note in the docs that latency queries need to filter http_request_method="POST".
There was a problem hiding this comment.
Fixed in 46864d8 — split SSE connection duration into a dedicated stacklok.toolhive.proxy.sse_connection.duration histogram on BucketsMCPProxy() (tops out at 300s), rather than sharing http.server.request.duration's 10s-capped buckets. Updated docs/observability.md with the new metric's section and a note on why they're kept separate. Rewrote TestHTTPMiddleware_HTTPServerRequestDuration_SSEBranch (now TestHTTPMiddleware_SSEConnectionDuration_SSEBranch) to assert http.server.request.duration is no longer emitted on the SSE branch and the new histogram is.
| promConfig := prometheus.Config{ | ||
| EnableMetricsPath: true, | ||
| IncludeRuntimeMetrics: true, | ||
| OwnershipLabels: map[string]string{ |
There was a problem hiding this comment.
These are OTel attribute keys (stacklok.component, with a dot), but OwnershipLabels is documented as Prometheus constant labels and goes straight into promclient.WrapRegistererWith at prometheus.go:48.
It works today, though only because model.NameEscapingScheme defaults to UnderscoreEscaping and rewrites the dot at exposition time. A scraper negotiating escaping=allow-utf-8, which Prometheus 3.x can do, gets stacklok.component verbatim on the go_* and process_* series while OTel-native series keep stacklok_component. A dashboard filtering on stacklok_component then silently drops all runtime metrics.
TestUnifiedMeterStrategy_PrometheusOwnershipLabels says it guards against "a wrong-form key (e.g. an OTel dotted attribute key instead of a Prometheus label name)" and passes anyway, for the same escaping reason. Passing stacklok_component and stacklok_product directly here makes it robust, and makes that test mean what it says.
There was a problem hiding this comment.
Confirmed and fixed in 46864d8. You're right that this was only accidentally safe under the default negotiation. I reproduced the split with escaping=allow-utf-8: runtime/process series exposed "stacklok.component" verbatim while OTel-native series stayed stacklok_component, since the OTel exporter's own attribute-to-label conversion is unconditionally underscore regardless of scrape negotiation. Switched OwnershipLabels to hardcoded Prometheus-form keys directly, with a comment explaining why the OTel dotted constants don't belong here. Also strengthened TestUnifiedMeterStrategy_PrometheusOwnershipLabels to negotiate escaping=allow-utf-8 in a second subtest and assert the dotted form never appears. Confirmed it fails against the pre-fix code and passes now.
| dashboard or alert querying an old metric or label name must be updated | ||
| regardless of that flag's setting. | ||
|
|
||
| | Legacy Metric/Label | New Metric/Label | Notes | |
There was a problem hiding this comment.
The left column holds real Prometheus names and the right column holds OTel dotted names, so anyone pasting a value from the right column into PromQL gets zero results. This needs fixing whichever cutover path we settle on, otherwise the guide can't actually be followed.
Rows needing _total: 286, 287, 289, 290, 294, 298, 300. Rows needing _seconds plus _bucket/_sum/_count: 288, 291, 292, 295, 299, 315, 316. And stacklok.vmcp.optimizer.token_savings carries unit %, so it exposes as stacklok_vmcp_optimizer_token_savings_percent, which happens to match the old Prometheus name exactly and is worth saying out loud.
Lines 324 to 330 do the translation correctly for the two HTTP/MCP metrics, which makes the omission in the tables read as deliberate. A Prometheus-name column would fix it, or one sentence above the tables covering dots to underscores, _total, and unit suffixes.
There was a problem hiding this comment.
Fixed in 46864d8. Added a Prometheus Name column to the mapping table with the correctly suffixed underscore form for every row (_total for counters, _seconds_bucket/_sum/_count for histograms, and the declared-unit suffix like _percent where applicable). Verified each suffix empirically by constructing the actual instrument kind and scraping it, rather than assuming the general rule holds for every case, since the unit suffixes (_percent, {tools} producing no unit infix) needed confirming.
| | `toolhive_vmcp_workflow_executions` | `stacklok.vmcp.composite_tool.executions` | Now split by `outcome` label instead of a separate errors counter | | ||
| | `toolhive_vmcp_workflow_errors` | `stacklok.vmcp.composite_tool.executions` (filtered to `outcome="error"`) | Merged into the executions counter above, not a standalone metric | | ||
| | `toolhive_vmcp_workflow_duration` | `stacklok.vmcp.composite_tool.duration` | | | ||
| | `toolhive_vmcp_backend_requests_duration` | `mcp.client.operation.duration` | OTEL MCP semconv histogram | |
There was a problem hiding this comment.
Small thing: toolhive_vmcp_backend_requests_duration appears twice, here as a rename and at line 319 in the deleted table, whose preamble says there's no renamed successor. Its two siblings (toolhive_vmcp_backend_requests and _backend_errors) only appear in the deleted table, so this row looks like a leftover. Dropping it clears up the contradiction.
There was a problem hiding this comment.
Fixed in 46864d8. Dropped the contradictory row from the mapping table; it's now listed only in the Deleted Legacy Metrics table where it belongs.
| // rebuild of the backend client does not accumulate callbacks against stale health state. | ||
| // | ||
| // The returned *HealthProviderSetter lets the caller attach a live health.StatusProvider | ||
| // once it becomes available (health.Monitor is built after MonitorBackends is called in |
There was a problem hiding this comment.
The parenthetical says health.Monitor "depends on the decorated client already existing", but buildHealthMonitor at core_vmcp.go:277 takes only cfg and deliberately passes the undecorated cfg.BackendClient. Its own comment at line 283 says as much: "Use the undecorated client so health checks do not emit backend-call telemetry."
So there's no construction-order cycle to work around. If buildHealthMonitor moves above the MonitorBackends call, the provider can be passed by value and HealthProviderSetter goes away entirely, along with its mutex, Set/get, the four-value return at line 102, and the if healthProviderSetter != nil block at core_vmcp.go:253.
Not blocking, but it's a fair bit of machinery for a problem that isn't there.
There was a problem hiding this comment.
You're right that buildHealthMonitor uses the undecorated client, so there's no construction-order dependency forcing this ordering. Reordering it before MonitorBackends and passing the health.StatusProvider by value would remove HealthProviderSetter, its mutex, Set/get, and the four-value return entirely. Agreed this is a real simplification, but it touches construction ordering across core.New and is a separate change from this PR's rename scope. Leaving as-is for now and will follow up separately rather than bundling a construction-order refactor into this review pass.
| // it was built for is never returned to a caller and so never has Close | ||
| // called on it. Table-driven over every error branch in New that runs after | ||
| // MonitorBackends succeeds (invalid AuditConfig, workflow-auditor construction | ||
| // failure, workflow-instrument creation failure, workflow validation failure, |
There was a problem hiding this comment.
Small thing: the comment lists five branches including "workflow-instrument creation failure", but the table has four cases and that one isn't among them. The newWorkflowInstruments error path at core_vmcp.go:202 is the one left unexercised. Either add the case or trim the comment.
There was a problem hiding this comment.
Fixed in 46864d8. Trimmed the comment to only list the four exercised branches, and added a note explaining why the newWorkflowInstruments error path isn't covered: the OTel SDK doesn't return an error for a same-name instrument-kind conflict (confirmed empirically), so there's no input reachable through the public API that would trigger it.
…olhive # Conflicts: # pkg/telemetry/middleware.go
Addresses #5956 review comments: - HIGH pkg/telemetry/middleware.go:713,732 (3684968852): mcp_server label missing from http.server.request.duration and mcp.server.operation.duration, losing per-server breakdown the deleted proxy metrics carried - HIGH pkg/telemetry/middleware.go:97 (3684968864): SSE connection lifetime recorded into http.server.request.duration's 10s-capped buckets; split into a dedicated stacklok.toolhive.proxy.sse_connection.duration histogram on wider buckets - HIGH pkg/telemetry/providers/providers_strategy.go:150 (3684968870): OwnershipLabels used dotted OTel attribute keys as Prometheus label names, which expose verbatim (not underscore-escaped) under Prometheus 3.x's escaping=allow-utf-8 negotiation, splitting one ownership label into two incompatible families depending on scraper - MEDIUM docs/telemetry-migration-guide.md:279 (3684968876): mapping table mixed real Prometheus names with OTel dotted names in one column; added a Prometheus Name column with correct _total/_seconds/_bucket/_percent suffixes - LOW docs/telemetry-migration-guide.md:292 (3684968881): dropped a contradictory row listing a deleted metric as also renamed - LOW pkg/vmcp/core/core_vmcp_test.go:173 (3684968894): trimmed a stale comment naming an untested branch that has no forceable path through the OTel SDK's public API Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the seven inline findings in 46864d8: mcp_server label restored on the two proxy request metrics, SSE connection duration split into its own histogram, the OwnershipLabels escaping split fixed, migration guide mapping table corrected, and the two small doc/comment issues cleaned up. On the cutover strategy: I don't have a counter-argument to D13 only covering the 6 deletions, not the 18 renames. Dual-emitting the renamed metrics behind a flag defaulting to on, keeping the 6 deleted twins one more release, and adding a startup WARN is a reasonable path and consistent with how span attributes are already handled via --otel-use-legacy-attributes. That's a design change beyond what I can fold into responses to this review's inline comments, so I'm not implementing it here. Will follow up on scoping that as a separate change. |
The test asserted http_response_status_code="200" >= 1 to prove an authorized SSE tool call succeeded. That assertion was only ever satisfied by the SSE connection-open GET's default 200 status leaking into the same metric family — an authorized tools/call POST over SSE transport gets 202 Accepted (the result arrives async over the stream), never 200. Splitting SSE connection duration into its own histogram (46864d8) removed that incidental 200, exposing the gap. Drop the 200 assertion and make the tools/call-count assertion (mcp_server_operation_duration_seconds_count) unconditional — it already existed as the correct signal but was gated behind a non-fatal substring check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ChrisJBurns
left a comment
There was a problem hiding this comment.
Cutover strategy: remove legacy attributes, but keep renamed metrics behind a flag
Seconding @aponcedeleonch's request — this is the one thing I'd want resolved before merge, and I want to add some evidence for why the rename half is the more dangerous half.
The position: retiring the legacy span attributes is fine. That surface already has a compatibility story — dual emission behind --otel-use-legacy-attributes, with #6072 tracking its retirement. Users get a flag, a window, and a documented path.
Renaming metrics has none of that today, and it should get the same treatment:
- Dual-emit the renamed metrics under both old and new names, behind an
--otel-legacy-metricsflag defaulting to on. - Keep the 6 deleted twins one more release. They already coexist with their semconv replacements today, so keeping them costs nothing new.
- Emit a startup WARN naming each deprecated metric and the release it will be removed in.
- Document the deprecation — an explicit old → new table plus a stated removal release. The mapping table in this PR is already most of the way there; it needs to be framed as a deprecation schedule rather than a one-shot cutover.
- Remove both in a later minor, and call it out prominently in the release notes.
Why the renames are worse than the deletions
I verified the counts against the mapping table at a8c3103b: 21 legacy toolhive_* metric names — 6 deleted, 15 renamed — plus 3 counters merged into outcome labels. After this PR, toolhive_ appears in zero metric names in non-test Go source.
The intuition here inverts, and I think it's the crux of the disagreement:
- The 6 deletions are the least disruptive category. Their semconv replacements (
mcp.server.operation.duration,mcp.client.operation.duration) already exist and are already emitted today, alongside the twins. A user can migrate their queries before upgrading, run both, confirm they agree, then upgrade. That overlap window is exactly what RFC D13's argument rests on. - The 15 renames + 3 merges have no overlap window at all. The old name stops and the new name starts in the same release. There is no moment where a user can run both and compare.
So D13 justifies the category that was already safe, and doesn't reach the category that isn't. A rename empties a dashboard panel exactly the same way a deletion does.
It also compounds: on the rate-limit metrics both the metric and its label change (toolhive_rate_limit_decisions{server=…} → stacklok_toolhive_ratelimit_decisions_total{mcp_server=…}), so those dashboards break twice over.
Three breakages not currently in the guide
- Histogram bucket boundaries change on metrics whose names survive.
mcp.server.operation.durationmoves fromBucketsMCPSemconv()[…0.02, 0.2, 2…]toBucketsMCPProxy()[…0.25, 2.5…]. Anyhistogram_quantile()spanning the upgrade mixes two incompatible layouts and returns silently wrong numbers. This is also the unresolved #6144 conflict — see the inline comment. telemetry.MCPHistogramBucketsis deleted from a public package. Exported identifier ingithub.com/stacklok/toolhive/pkg/telemetry, currently referenced from six places in-repo. External importers break at compile time, not just at query time. Not mentioned anywhere in the guide.stacklok.build_infois documented under a name Prometheus never emits. It exports asstacklok_build_info_ratio(theWithUnit("1")→_ratiorule). Reproduced against this repo's pinned deps. So even a user who does the migration work lands on a query matching nothing.
And docs/telemetry-migration-guide.md:213 still tells operators "Your existing dashboards and alerts continue to work without changes" — which contradicts the Backward Compatibility table two sections above it.
One note on risk assessment: green CI isn't much signal here. The repo ships no PrometheusRule or alert artifacts, so nothing in-repo can catch a bad metrics migration. The first signal is a customer's dashboard going blank.
Review findings
Reviewed at a8c3103b across six specialist passes (OTel semconv, Go concurrency/lifecycle, backwards-compat/docs, test coverage, security/config, architecture). Claims marked verified were reproduced against this repo's pinned dependencies.
Already fixed in 46864d8e — re-verified, no action needed: mcp_server restored on both proxy request metrics; OwnershipLabels switched to literal Prometheus label names; SSE split into its own stacklok.toolhive.proxy.sse_connection.duration on BucketsMCPProxy(). Good fixes.
Consensus summary
| Severity | Finding | Location |
|---|---|---|
| HIGH | http.request.method recorded raw — unbounded attacker-controlled cardinality; semconv _OTHER normalization missing |
middleware.go:747 |
| HIGH | Semconv histogram buckets switched to the coarser proxy preset — reverts #6144, breaks quantile continuity | middleware.go:86 |
| HIGH | stacklok.build_info exports as stacklok_build_info_ratio; test passes on substring |
buildinfo_test.go:41 |
| HIGH | Step 1 still promises existing dashboards keep working | telemetry-migration-guide.md:213 |
| HIGH | Documented cardinality bound doesn't exist — gen_ai.tool.name gets the same unbounded value |
observability.md:231 |
| MEDIUM | build_info registered per-middleware, not once per process, with no unregister — doc overclaims |
middleware.go:127 |
| MEDIUM | mcp.method.name is an unvalidated JSON-RPC method string from the request body |
middleware.go:707 |
| MEDIUM | backendHealth.states never pruned — unbounded map keyed by workload ID |
backendtelemetry.go:242 |
| MEDIUM | Health gauge flips to unhealthy on context.Canceled and auth failures |
backendtelemetry.go:373 |
| MEDIUM | HealthProviderSetter's two-phase init rests on a premise that isn't true |
backendtelemetry.go:203 |
| MEDIUM | Unregister cleanup manually replicated across 5 error paths | core_vmcp.go:163 |
| MEDIUM | Optimizer tool_name is unvalidated client input, now multiplied by outcome |
factory.go:418 |
| MEDIUM | Test: "health monitor build fails" case is vacuous; doc comment claims a 5th branch with no case | core_vmcp_test.go:226 |
| MEDIUM | Test: no dp.Count assertion (double-record passes) and no 4xx case for the documented narrowing |
middleware_test.go:2555 |
| MEDIUM | Test: tautological guard gates the positive assertions | integration_test.go:181 |
| MEDIUM | Test: e2e helper passes silently on zero regex matches | telemetry_metrics_validation_e2e_test.go:360 |
| MEDIUM | Test: validateNoEmptyLabels has no zero-match guard after narrowing to one metric |
telemetry_metrics_validation_e2e_test.go:334 |
| MEDIUM | Test: dropped server-identity assertions without replacement | telemetry_integration_test.go:338 |
| MEDIUM | "What Is New" table missing the three genuinely new metrics | telemetry-migration-guide.md:29 |
Not anchorable inline (outside the diff)
examples/otel/grafana-dashboards/toolhive-mcp-otel-semconv-dashboard.json:246and:974— the semconv dashboard's own Error Rate panels still useerror_type!="", which is exactly the 4xx trap the migration guide warns about. The other three dashboards were correctly moved tohttp_response_status_code=~"[45].."; these two weren't, so the shipped dashboards now disagree on what "error rate" means and this one silently under-reports 403s and 429s.docs/arch/10-virtual-mcp-architecture.md:885— still references the removedmcp_methodlabel. CLAUDE.md requiresdocs/arch/to be updated when architecture changes..claude/agents/site-reliability-engineer.md:23— still points at the deletedpkg/vmcp/server/telemetry.go.- Three rewritten dashboards lack
sum by (...)—rate(mcp_server_operation_duration_seconds_count{…})withlegendFormat: "{{mcp_method_name}}"and no aggregation fanstools/callout to one series per tool, all rendered with the same legend string. The semconv dashboard does this correctly (sum by (le, mcp_method_name)), so the four are now inconsistent. docs/operator/virtualmcpserver-observability.md— the migration guide now points readers here for the optimizer andrevision_reclassificationsmetrics, but this doc documents none of them.
Worth noting on the positive side
The callback lifecycle work holds up under scrutiny: every one of the five post-registration error paths in core.New unregisters, Close() is idempotent (the SDK's Unregister is safe twice), the observable callback is genuinely non-blocking (registry.List is an RLock map copy), and the shared state it touches is race-free. The rename itself is also complete — repo-wide greps turn up no missed producer or consumer in deploy/, charts, ServiceMonitors, or alert rules. And replacing nil instruments with noop.* fallbacks quietly fixes a pre-existing panic path.
Scope
At 751 lines of production change across 9 files this is ~88% over the 400-line budget in CLAUDE.md, and three genuine behaviour changes (the health gauge, http.server.request.duration, the D8 hardening) are interleaved with a rename-dominated diff. Given the PR is already 20+ commits deep I don't think splitting now is worth it — but the backend-health gauge is the piece that would most have benefited from separate review.
🤖 Generated with Claude Code
| ctx context.Context, method string, statusCode int, duration time.Duration, | ||
| ) { | ||
| specAttrs := []attribute.KeyValue{ | ||
| attribute.String("http.request.method", method), |
There was a problem hiding this comment.
HIGH — http.request.method is recorded raw: unbounded, attacker-controlled cardinality.
Go's net/http accepts any RFC 7230 token as a method, and /metrics plus the MCP port are unauthenticated by default here. curl -X ZZZZ1, -X ZZZZ2, … mints a permanently-retained series per distinct method — each multiplied across the bucket set — in both the SDK's in-memory aggregation and Prometheus.
OTel HTTP semconv mandates exactly the missing mitigation: methods outside the known set MUST be recorded as _OTHER, with the original kept on spans only (http.request.method_original, which should not go on the metric).
The deleted toolhive_mcp_requests carried the same raw method, so this isn't net-new — but it's reintroduced on a metric this PR presents as semconv-compliant, and it's now the one metric recorded on every request path.
Separately, semconv lists url.scheme as Required on http.server.request.duration, and network.protocol.version / server.address as recommended. httpProtocolVersion(r) and mapTransport() are already computed a few lines down for mcp.server.operation.duration, so the data is at hand. http.route should be added only from a bounded route pattern — never the raw r.URL.Path.
| metric.WithDescription("Duration of MCP server operations"), | ||
| metric.WithUnit("s"), | ||
| metric.WithExplicitBucketBoundaries(MCPHistogramBuckets...), | ||
| metric.WithExplicitBucketBoundaries(coremetrics.BucketsMCPProxy()...), |
There was a problem hiding this comment.
HIGH — this reverts #6144, and changes bucket edges on an already-shipped metric.
247480f16 ("Adopt core MCP semconv bucket preset in telemetry", #6144) landed on main and deliberately put this exact line on BucketsMCPSemconv(). This branch moves it to BucketsMCPProxy(). @aponcedeleonch flagged this as the live rebase conflict and it's still unresolved at a8c3103b.
toolhive-core's own doc comment says BucketsMCPSemconv exists "for emitters that promise semconv fidelity" and names mcp.server.operation.duration as the example — so a semconv-named metric should carry semconv buckets.
Beyond the revert, the edges themselves change: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, …] → [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, …]. Any histogram_quantile() over a range spanning the upgrade mixes two incompatible layouts and returns silently wrong values. The migration guide documents zero bucket changes.
pkg/ratelimit/observability.go:80 and pkg/vmcp/core/core_telemetry.go:57 make the same substitution without conflicting textually and want the same look. On the latter: toolhive-core documents BucketsLongRunning() as being "for long-running measurements such as sync, reconcile, or composite-tool durations" — naming that exact use case.
| handler.ServeHTTP(rw, req) | ||
| body := rw.Body.String() | ||
|
|
||
| require.Contains(t, body, "stacklok_build_info", "build_info metric should be exported") |
There was a problem hiding this comment.
HIGH — this assertion cannot fail for the property it claims to check, and the documented metric name doesn't exist.
coremetrics.RegisterBuildInfo sets metric.WithUnit("1"), and the Prometheus translator appends _ratio to any gauge with unit "1". I reproduced this against this repo's pinned deps:
stacklok_build_info_ratio{commit="abc123",component="toolhive",version="v1.2.3"} 1
require.Contains(t, body, "stacklok_build_info") is satisfied by stacklok_build_info_ratio as a substring, so the test passes while docs/observability.md:271 documents a name that matches nothing. Any fleet dashboard joining on stacklok_build_info returns empty.
Anchor the assertion at line start (^stacklok_build_info(_ratio)?\{) so the exported name is pinned, and reconcile with the docs. The durable fix is upstream — drop WithUnit("1") for a unitless identity gauge — worth filing before this vocabulary reaches customer dashboards.
Also worth asserting the labels: build_info always observes 1, so component="toolhive" / non-empty version / non-empty commit are the entire point of the metric, and none of them is currently checked.
| @@ -178,15 +212,17 @@ When upgrading to this release, dual emission is enabled by default. Both old | |||
| and new attribute names appear on spans. Your existing dashboards and alerts | |||
| continue to work without changes. | |||
There was a problem hiding this comment.
HIGH — this is now false, and it's the first thing an operator reads when deciding whether an upgrade is safe.
Six metrics are deleted, 15 renamed, 3 merged, and several label keys changed. The Backward Compatibility table two sections above explicitly states metrics are a hard cutover, no legacy fallback — this paragraph directly contradicts it.
The dual-emission reassurance is true only for span attributes. Suggest retitling to scope it explicitly, e.g. "Step 1: Upgrade — Span Attributes Need No Action", and stating plainly that metric-based dashboards and alerts do break, with a pointer to Step 2.
Related: Step 4 ("Update Dashboard Panels") still advises running old and new queries "side-by-side during migration to verify equivalence" — impossible for metrics with no dual emission. Worth rescoping to trace-based panels.
|
|
||
| #### `toolhive_mcp_tool_calls` (Counter) | ||
| > **Note**: `mcp_resource_id` (tool name, resource URI, or prompt name) is | ||
| > deliberately omitted from this metric's attributes to bound cardinality; it |
There was a problem hiding this comment.
HIGH — this cardinality claim doesn't hold, and it will mislead operators into thinking the vector is closed.
recordOperationDuration (middleware.go:791) sets gen_ai.tool.name to resourceID — the same parsedMCP.ResourceID value, i.e. params.name straight from the client's JSON-RPC body. For tools/call and prompts/get the unbounded value simply moved to a different key on the same metric; only resources/read (the URI) genuinely lost it.
Since mcp.server.operation.duration is now the sole per-method metric after the twins were deleted, arbitrary params.name values grow the series set without bound.
Either keep gen_ai.* names on spans only, or bound them against the aggregated capability set the proxy already knows (emitting _OTHER for unresolved names). Either way the note needs correcting — as written it asserts a control that isn't there.
| // The renamed active-connections gauge and the semconv HTTP duration must | ||
| // be present (both are recorded for every handled request). | ||
| if strings.Contains(metricsBody, "stacklok_toolhive_proxy") { | ||
| assert.Contains(t, metricsBody, metricActiveConnections) |
There was a problem hiding this comment.
MEDIUM — this guard is a prefix of the string it guards, so the assertion is tautological.
metricActiveConnections == "stacklok_toolhive_proxy_active_connections", and the guard is strings.Contains(metricsBody, "stacklok_toolhive_proxy"). The first assertion can therefore never fail, and the second only runs when the gauge already happens to be present. If the middleware emitted nothing at all, the whole block is skipped and the test still passes.
The test already made a real request through the middleware, so both series must exist — dropping the if and asserting unconditionally is strictly stronger.
(The NotContains assertions above this are unconditional and genuinely valuable — this is only about the guarded positive half.)
| @@ -350,12 +358,13 @@ func validateNoEmptyLabels(metricsContent, expectedServerName, expectedTransport | |||
|
|
|||
| // validateMetricValues validates that metric values are reasonable | |||
| func validateMetricValues(metricsContent, expectedServerName, expectedTransport string) { | |||
There was a problem hiding this comment.
MEDIUM — the regex was weakened and the helper still can't fail.
The pattern went from toolhive_mcp_requests_total\{.*server="X".*transport="Y".*\} (\d+) to mcp_server_operation_duration_seconds_count\{[^}]*\} (\d+), dropping both the server-name and transport pinning. The surrounding if len(matches) > 0 { … } guard is left in place, so if the regex matches nothing the entire body — including Expect(totalRequests).To(BeNumerically(">", 0)) — is skipped and the helper passes silently.
Expect(matches).ToNot(BeEmpty(), "expected ... series in scrape") makes it capable of failing. Now that 46864d8e has restored mcp_server on this metric, the server-identity pinning can also come back.
|
|
||
| for _, line := range lines { | ||
| if strings.Contains(line, "toolhive_mcp") && !strings.HasPrefix(line, "#") { | ||
| // The active-connections gauge is the series that carries both mcp_server |
There was a problem hiding this comment.
MEDIUM — no zero-match guard after narrowing the scan from many series to one.
This previously scanned every line containing toolhive_mcp; it now scans only stacklok_toolhive_proxy_active_connections. If that single gauge is missing from the scrape for any reason — instrument creation fell back to the new noop.Int64UpDownCounter{}, a rename, an exporter change — the loop body never executes and the helper passes without asserting anything.
Unlike analyzeTraceAttributes, whose caller asserts TracesGenerated > 0 downstream, there's no backstop here. A checkedLines counter plus Expect(checkedLines).To(BeNumerically(">", 0)) closes it.
| // Request duration histogram | ||
| assert.Contains(t, metrics, "toolhive_mcp_request_duration", | ||
| "Should record request duration histogram") | ||
| // --- Deleted legacy twins must be absent (single-pass cutover) --- |
There was a problem hiding this comment.
MEDIUM — server-identity assertions were dropped without replacement, in the one test that scrapes a real vMCP /metrics.
The rewrite deleted assert.Contains(metrics, 'server="telemetry-vmcp"'), 'transport="streamable-http"', and 'mcp_resource_id="search-svc_search"', replacing them only with mcp_method_name= checks. So the central server → mcp_server rename this PR performs isn't verified end-to-end anywhere. Same in TestIntegration_TelemetryRunsBeforeClassificationRejection, where server="telemetry-ordering-vmcp" gave way to a status-code check.
mcp_server="telemetry-vmcp" now lands on stacklok_toolhive_proxy_active_connections and mcp_client_operation_duration_seconds, so asserting it is cheap. The transport= assertion is unaffected by this PR and could simply come back.
| 3. **Metric name and label standardization** — The legacy `toolhive_mcp_*` and | ||
| `toolhive_vmcp_*` metric names and their label vocabulary (`server`, | ||
| `mcp_method`, `tool`, `workflow.name`, …) have been replaced by the shared | ||
| `stacklok.*`/OTel-semconv vocabulary (`mcp_server`, `mcp_method_name`, |
There was a problem hiding this comment.
MEDIUM — the "What Is New" table contradicts the "What Changed" section above it.
"What Changed" was updated to describe three new histograms, but this table still lists only mcp.server.operation.duration and mcp.client.operation.duration. Missing:
http.server.request.durationstacklok.toolhive.proxy.sse_connection.duration(added in46864d8e)stacklok.build_infostacklok.vmcp.mcp_server.health— semantically new, not a rename oftoolhive_vmcp_backends_discovered- the new
outcomelabel vocabulary
Worth adding here since this table is what a reader scans to find out what they gain in exchange for the queries they have to rewrite.
Summary
The proxy and vMCP used
toolhive_mcp_*/toolhive_vmcp_*metric names, emitted six legacy metrics alongside their emerging-semconv replacements, and diverged from the platform label vocabulary. This finishes the semconv migration and adopts the sharedtoolhive-corevocabulary, per the Metrics Standardization RFC.pkg/telemetry/):toolhive_mcp_active_connections→stacklok.toolhive.proxy.active_connections;build_infoviacoremetrics.RegisterBuildInfo; addedhttp.server.request.duration(OTel HTTP semconv) so deleting the legacy MCP twins doesn't drop transport-level coverage; D8 ownership labels (stacklok_component=toolhive,stacklok_product=stacklok-platform) promoted to every series viaWithResourceAsConstantLabelsand hardened as the final OTEL resource detector so a CLI custom attribute orOTEL_RESOURCE_ATTRIBUTESentry for those keys cannot override the frozen values.pkg/vmcp/):toolhive_vmcp_workflow_*→stacklok.vmcp.composite_tool.*; backendmcp_server.healthgauge rename to a live per-(mcp_server, state)gauge, keyed by workload ID for consistency with the backend registry and health-status provider; optimizer renames +token_savings.toolhive_mcp_requests,toolhive_mcp_request_duration,toolhive_mcp_tool_calls,toolhive_vmcp_backend_requests,toolhive_vmcp_backend_errors,toolhive_vmcp_backend_requests_duration). Their semconv replacements (mcp.server.operation.duration,mcp.client.operation.duration,http.server.request.duration) are kept.toolhive-coredependency tov0.0.34, the release carryingtelemetry/metrics(adopts core label keys, outcome values, and bucket presets). The transitive AWS-SDK and grpc bumps ingo.modcome fromtoolhive-core's own requirements.test/integration/vmcp/,test/e2e/) that asserted the deleted/renamed metric names and old label scheme (mcp_method→mcp_method_name,server→mcp_server,status→HTTP status code) to assert the semconv replacements.docs/telemetry-migration-guide.mdfor the renamed/deleted metrics, including a complete old→new metric and label mapping and a correctedMCP Request Ratevs. total HTTP request-rate query split.examples/otel/prometheus-stack-values.yaml) so the updated example dashboards can be auto-provisioned into a local Kind cluster via a labeled ConfigMap, for validating the renamed metrics land correctly end-to-end.pkg/ratelimit/observability.go, missed by the original pass:toolhive_rate_limit_decisions/_redis_errors/_check_latency→stacklok.toolhive.ratelimit.{decisions,redis_errors,check_latency}, with the migration guide anddocs/observability.mdupdated to match.toolhive_vmcp_backend_revision_reclassifications(added independently onmainafter this branch forked) tostacklok.vmcp.backend.revision_reclassificationsso it doesn't reintroduce the legacy naming scheme this PR retires.docs/telemetry-migration-guide.md's Backward Compatibility section: span attributes and metrics follow different policies (dual-emission vs. hard cutover) for different reasons — the guide previously implied one uniform strategy.Type of change
Test plan
task test)task lint-fix)Verified the shipped standalone path (
GOWORK=off, publishedtoolhive-core v0.0.34, no workspace):GOWORK=off go build ./...— clean.GOWORK=off go test ./pkg/telemetry/... ./pkg/vmcp/...— all packages pass, including the D8 label-promotion assertion inpkg/telemetry/buildinfo_test.go(stacklok_component="toolhive"/stacklok_product="stacklok-platform"promoted to per-series labels) and the updatedTestVMCPServer_Telemetry_CompositeToolMetricsintegration test.task test(full suite) is green.test/e2e/) were updated for the new metric/label vocabulary and compile clean (go vet); they require a fulltask test-e2erun (containers) to exercise end-to-end.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Does this introduce a user-facing change?
Yes. Prometheus metric names emitted by the proxy, vMCP, and rate limiting change to the
stacklok.*namespace, and six legacytoolhive_*twins are removed. Their OTel-semconv replacements cover the same signals;docs/telemetry-migration-guide.mdmaps old → new. A customer scraping the old names loses those series (RFC D13 accepts this: the deleted twins are not scraped in a default shipped deployment).Special notes for reviewers
toolhive-corev0.0.34(the shared-vocabulary release); this PR'sgo.modpins it.toolhive-core v0.0.34trims theComponent*value roster a pre-release build referenced; toolhive defines its ownstacklok.component = "toolhive"constant (pkg/telemetry/middleware.go,pkg/telemetry/providers/providers.go), asserted bypkg/telemetry/buildinfo_test.go.pkg/vmcp/internal/backendtelemetry/backendtelemetry.go's health gauge resolves each backend's status with a three-tier precedence — a livehealth.StatusProviderfirst, then request-outcome tracking, then the registry's discovery-time snapshot — matching the same precedencefilterHealthyBackendsuses for capability aggregation.core.Newunregisters the gauge's callback on every construction-failure path after registration succeeds, and onClose(), so a partially-constructedcoreVMCPnever leaks a callback against the shared meter provider.Links
useLegacyAttributesspan-attribute dual-emission flag (out of scope here; metrics/labels in this PR use a hard cutover with no such flag).Ref: https://github.com/stacklok/stacklok-enterprise-platform/issues/1380
Generated with Claude Code